Conversation
|
|
@claude review |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
|
@claude review |
|
@claude review |
|
@claude review |
|
Todo:
|
|
@claude review |
|
@claude review |
|
@claude review |
|
@claude review |
| "files": [ | ||
| "dist" | ||
| ], |
There was a problem hiding this comment.
🟡 The new @modelcontextprotocol/codemod package is added to the publish workflow but ships with no README.md, while every other published package in the repo (client, server, middleware/*) has one. The PR description's CLI usage, programmatic API, and transform table won't be visible on the npm package page — worth adding a packages/codemod/README.md before this lands (npm auto-includes README regardless of the files array, so no package.json change is needed).
Extended reasoning...
What the gap is
This PR adds @modelcontextprotocol/codemod as a new published package — it has a bin entry (mcp-codemod), a programmatic API export, and is added to .github/workflows/publish.yml:43 so pkg-pr-new is already publishing preview builds. But packages/codemod/ contains no README.md. Every other published package in the repo has one: packages/client/README.md, packages/server/README.md, and all four packages/middleware/*/README.md files exist.
The repo's own review checklist (REVIEW.md) explicitly calls this out: "New feature: verify prose documentation is added (not just JSDoc), and assess whether examples/ needs a new or updated example." A new package with both a CLI and a programmatic API is squarely in scope for that item.
Why it matters
The PR description already contains exactly the documentation users need — the CLI Usage block (mcp-codemod v1-to-v2 ./src, --dry-run, --transforms, --list, --ignore), the Programmatic API snippet (getMigration / run), and the 9-row transform table. None of that will be visible on npm. A user who runs npx @modelcontextprotocol/codemod and gets the commander help text will have nowhere to look for the transform IDs, the design decisions, or the migration scope.
Step-by-step proof
.github/workflows/publish.yml:43adds'./packages/codemod'to thepkg-pr-new publishcommand, so the package is published on every PR push (and presumably on release).packages/codemod/package.json:32-34sets"files": ["dist"]and abinentry — so the package is intended for end-user consumption vianpx.ls packages/codemod/shows:eslint.config.mjs,package.json,src/,test/,tsconfig.json,tsdown.config.ts,typedoc.json,vitest.config.js— noREADME.md.ls packages/*/README.md packages/middleware/*/README.mdshows READMEs for client, server, express, fastify, hono, and node — codemod is the only published package without one.- On npm, the package page renders the README as the primary documentation surface. With no README, the page shows only the
package.jsondescription string: "Codemod to migrate MCP TypeScript SDK code from v1 to v2" — no usage, no flags, no transform list.
Note on files array
npm always includes README* (along with package.json, LICENSE*, and the main entry) regardless of the files allowlist, so adding packages/codemod/README.md is sufficient — no need to touch "files": ["dist"].
Fix
Add packages/codemod/README.md containing (at minimum) the CLI Usage block, the Programmatic API snippet, and the transform table from this PR's description. Given the PR is explicitly titled "draft" and the author has an open TODO checklist, this is likely already planned — flagging it here per the REVIEW.md checklist item so it doesn't slip.
There was a problem hiding this comment.
TBD. Add README.md once the feasibility of the codemod has been confirmed.
|
@claude review |
| export function isImportedFromMcp(sourceFile: SourceFile, symbolName: string): boolean { | ||
| return sourceFile.getImportDeclarations().some(imp => { | ||
| if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false; | ||
| return imp.getNamedImports().some(n => n.getName() === symbolName); | ||
| }); | ||
| } |
There was a problem hiding this comment.
🟡 isImportedFromMcp() compares against n.getName() (the export name), but callers at handlerRegistration.ts:40 and schemaParamRemoval.ts:31 pass firstArg.getText()/secondArg.getText() — the local binding name — so an aliased MCP import plus a same-named local import confuses the provenance check. The new test 'does not remove non-MCP import when MCP import of same name is consumed' (handlerRegistration.test.ts:108-119) actually locks in the wrong behavior: CallToolRequestSchema there is bound to ./local-schemas.js, yet the test asserts it's rewritten to 'tools/call'. Note that mcpServerApi.ts:15 and expressMiddleware.ts:12 pass hardcoded export names and rely on the current semantics, so the fix is to check the local binding (n.getAliasNode()?.getText() ?? n.getName()) only for the body-identifier callers — e.g., a separate helper or an optional flag — rather than changing the function unconditionally.
Extended reasoning...
What the bug is
isImportedFromMcp(sourceFile, symbolName) (importUtils.ts:76-81) tests imp.getNamedImports().some(n => n.getName() === symbolName). ts-morph's ImportSpecifier.getName() returns the export name (the identifier before as), not the local alias — this is the same fact already established and acted on elsewhere in this PR (e.g., resolved comment 3132456083, and removedApis.ts using foundImport.getAliasNode()?.getText() ?? 'IsomorphicHeaders' to derive the local name).
Two of the four callers pass the local identifier text as it appears in the body:
handlerRegistration.ts:40—schemaName = firstArg.getText()schemaParamRemoval.ts:31—schemaName = secondArg.getText()
For these callers, comparing the body identifier against the export name is a category error whenever an alias is involved.
The concrete manifestation: a test that asserts wrong behavior
The most actionable evidence is the new test at handlerRegistration.test.ts:108-119:
import { CallToolRequestSchema } from './local-schemas.js';
import { CallToolRequestSchema as McpSchema } from '@modelcontextprotocol/sdk/types.js';
server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [] }));
validateSchema(McpSchema);In this fixture, the CallToolRequestSchema passed to setRequestHandler is the local binding from ./local-schemas.js — the MCP one is aliased to McpSchema. The test asserts expect(result).toContain("'tools/call'") and not.toMatch(/setRequestHandler\(CallToolRequestSchema/), i.e., it expects the local schema usage to be rewritten as if it were the MCP schema. That's the false positive, encoded as expected behavior. (The test was presumably written to verify that removeUnusedImport(..., onlyMcpImports=true) doesn't strip the local import — which it does verify — but the rewrite assertion is incorrect.)
Step-by-step proof (false positive)
firstArg.getText()='CallToolRequestSchema'.ALL_SCHEMA_TO_METHOD['CallToolRequestSchema']='tools/call'→ passes line 38.isImportedFromMcp(sf, 'CallToolRequestSchema'): iterates imports; the second declaration is an MCP specifier; its named import hasgetName() === 'CallToolRequestSchema'(the export name on the aliased specifier) → returns true.firstArg.replaceWithText("'tools/call'")— butfirstArgreferred to the local./local-schemas.jsbinding, not the MCP one.removeUnusedImport(sf, 'CallToolRequestSchema', true)then scans for body references toCallToolRequestSchema, finds none (it was just replaced), and — correctly — only removes the MCP-aliased specifier? Actually no:removeUnusedImportat importUtils.ts:96 also matches onnamedImport.getName()(export name), so it would try to remove the aliased MCP specifier, butMcpSchemais still referenced invalidateSchema(McpSchema), soreferenceCount > 0and nothing is removed. Hence the local import survives (which is what the test's first assertion checks), but the body call was still wrongly rewritten.
Addressing the refutation
A reviewer objected that (a) the proposed fix breaks the other two callers, (b) the false-negative paths are gated elsewhere, and (c) the trigger is contrived. All three points are fair and worth incorporating:
- (a) is correct and important.
mcpServerApi.ts:15andexpressMiddleware.ts:12pass literal export names ('McpServer','hostHeaderValidation') as a file-level gate. Forimport { McpServer as Server },n.getName() === 'McpServer'is exactly what they want. Changing the function to only check the alias would regress them. So the fix is not the one-liner originally proposed; it's either a second helper (e.g.,isLocalIdentifierFromMcp) for the body-identifier callers, or having the function match either form and letting callers 1/2 pass the local text while callers 3/4 keep passing the export name — but "match either" doesn't fix the false positive, so a separate helper is cleaner. - (b) is largely correct. The false-negative in
handlerRegistrationis moot becauseALL_SCHEMA_TO_METHOD[aliasName]is undefined and short-circuits first. InschemaParamRemoval,isSchemaReferencerequires the text to end in'Schema', so an alias likeRSis filtered out before the provenance check; only an alias that itself ends inSchema(e.g.,as ResultSchema) reaches the bug and is then silently skipped — safe (no corruption), just an incomplete migration. - (c) is correct on frequency, which is why this is a nit. But the fact that the test suite itself encodes the false positive as expected output is worth a one-line correction regardless of how rare the pattern is in the wild — tests that assert wrong behavior tend to bite later.
Impact
Nit severity. The output-corrupting path requires importing the same export name from both an MCP package (aliased) and a non-MCP source in one file — uncommon. The false-negative paths are mostly gated by earlier checks and, where reachable, leave code unchanged. The main value of the fix is (1) correcting the test so it doesn't lock in a wrong rewrite, and (2) eliminating an internal inconsistency where one helper serves two semantically different caller intents.
Fix
Introduce a local-binding variant for the body-identifier callers and leave the existing function for the file-level gates:
export function isLocalIdentifierFromMcp(sourceFile: SourceFile, localName: string): boolean {
return sourceFile.getImportDeclarations().some(imp => {
if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false;
return imp.getNamedImports().some(n => (n.getAliasNode()?.getText() ?? n.getName()) === localName);
});
}Use it at handlerRegistration.ts:40 and schemaParamRemoval.ts:31. Then update the test at handlerRegistration.test.ts:108-119 to assert the correct behavior: the setRequestHandler(CallToolRequestSchema, ...) call (local binding) is not rewritten, and 'tools/call' does not appear.
There was a problem hiding this comment.
Thanks — the false-positive is fixed (handlerRegistration/schemaParamRemoval now resolve via local binding + resolveOriginalImportName), and the test at handlerRegistration.test.ts now asserts correct behavior. 👍
One leftover: isImportedFromMcp was changed unconditionally to match on the local binding, which regresses the file-level-gate caller the comment flagged. mcpServerApi.ts:15 still passes the hardcoded export name 'McpServer', so for import { McpServer as Server } from '@modelcontextprotocol/server' the gate now returns false (the local name is 'Server') and every .tool()/.prompt()/.resource() call in that file is silently skipped — previously n.getName() === 'McpServer' matched the export name on the aliased specifier and the transform ran. (expressMiddleware.ts:12 is moot in practice since its body check at line 32 also matches on the literal identifier text.)
"Match either form" won't work here — it would re-introduce the false positive (the comment's refutation point (a) covers this). Simplest fix is a second helper for the export-name semantics and switch mcpServerApi.ts:15 to it:
export function isExportImportedFromMcp(sourceFile: SourceFile, exportName: string): boolean {
return sourceFile.getImportDeclarations().some(imp =>
isAnyMcpSpecifier(imp.getModuleSpecifierValue()) &&
imp.getNamedImports().some(n => n.getName() === exportName)
);
}(Low-frequency edge case, so still nit — but it's a behavioral regression from the previous commit.)
|
@claude review |
| if (Node.isExportSpecifier(parent)) { | ||
| if (!parent.getAliasNode()) parent.setAlias(oldName); | ||
| parent.getNameNode().replaceWithText(newName); | ||
| return; | ||
| } |
There was a problem hiding this comment.
🟡 The ExportSpecifier branch added for comment 3136237226 checks only Node.isExportSpecifier(parent) without verifying whether the matched identifier is the name node or the alias node — unlike every sibling guard (lines 15-24) which checks parent.getNameNode() === node. For export { Foo as McpError }, forEachDescendant visits the alias identifier McpError, parent.getAliasNode() is truthy so setAlias is skipped, then parent.getNameNode().replaceWithText('ProtocolError') overwrites Foo → export { ProtocolError as McpError }, silently dropping the user's local binding. Add if (parent.getAliasNode() === node) return; at the top of the branch.
Extended reasoning...
What the bug is
The ExportSpecifier branch at astUtils.ts:10-14 was added in response to comment 3136237226 to rewrite export { McpError } → export { ProtocolError as McpError }. But it only checks Node.isExportSpecifier(parent) and then unconditionally operates on parent.getNameNode() — it never verifies whether the matched identifier node is the name node (the local binding, before as) or the alias node (the public export name, after as). Every other guard in this function (lines 15-24: PropertyAssignment, PropertyAccessExpression, PropertySignature, MethodDeclaration, BindingElement, etc.) explicitly checks parent.getNameNode() === node before acting; this branch is the inconsistent one.
Code path that triggers it
In ts-morph, for export { Foo as McpError }, ExportSpecifier.getNameNode() returns the Foo identifier and getAliasNode() returns the McpError identifier. forEachDescendant visits both identifiers. When it reaches the alias McpError:
Node.isIdentifier(node) && node.getText() === 'McpError'→ true (matchesoldName).Node.isExportSpecifier(parent)→ true.parent.getAliasNode()is truthy (it's theMcpErrornode) →setAliasis skipped.parent.getNameNode().replaceWithText('ProtocolError')→ replacesFoowithProtocolError.
Result: export { ProtocolError as McpError }. The user's local Foo binding is silently dropped, and ProtocolError may not even exist as a local binding (if the file imported the unaliased MCP McpError, the import was renamed to ProtocolError, so it happens to resolve — but the semantics changed: the module now exports the MCP error class instead of the user's Foo).
Why existing code doesn't prevent it
The branch correctly handles two of three cases: (a) export { McpError } — no alias, setAlias(oldName) then rename → export { ProtocolError as McpError } ✓; (b) export { McpError as Something } — forEachDescendant matches the name node McpError, alias is truthy so setAlias skipped, name replaced → export { ProtocolError as Something } ✓. But case (c) export { Foo as McpError } — where only the alias matches oldName — falls through the same code path and corrupts the name slot. The new test 'preserves export specifier public name with alias' (symbolRenames.test.ts) only covers case (a). No test covers an export specifier whose alias matches a SIMPLE_RENAMES key.
Step-by-step proof
Input (file imports MCP McpError to trigger renameAllReferences, and separately re-exports a local class under the same public name):
import { McpError } from '@modelcontextprotocol/sdk/types.js';
class AppError extends Error {}
export { AppError as McpError };
if (e instanceof McpError) { ... }symbolRenamesTransformfindsMcpErrorin the MCP import, callsnamedImport.setName('ProtocolError'), thenrenameAllReferences(sf, 'McpError', 'ProtocolError').forEachDescendantvisits the import specifier →Node.isImportSpecifier(parent)returns early. ✓- Visits
McpErrorin theinstanceofcheck → falls through tonode.replaceWithText('ProtocolError'). ✓ - Visits the alias
McpErrorinexport { AppError as McpError }→parentisExportSpecifier,getAliasNode()truthy →getNameNode().replaceWithText('ProtocolError')rewritesAppError.
Output:
import { ProtocolError } from '@modelcontextprotocol/sdk/types.js';
class AppError extends Error {}
export { ProtocolError as McpError }; // ← was AppError; now exports the MCP class instead
if (e instanceof ProtocolError) { ... }The module's public McpError export silently changed from the user's AppError to the SDK's ProtocolError — still compiles, no diagnostic, semantically wrong. (One verifier reproduced this empirically against ts-morph.)
Impact
Nit severity: the trigger requires a file that both (a) imports a SIMPLE_RENAMES key unaliased from MCP (to fire renameAllReferences) and (b) re-exports a different local under that same public name — uncommon but valid TypeScript. When it fires, though, it's the worst codemod failure mode: silent semantic corruption that type-checks. And it's a one-line internal-consistency fix in a branch that was just added in response to review.
Fix
Add a guard at the top of the branch matching the pattern used by every sibling:
if (Node.isExportSpecifier(parent)) {
if (parent.getAliasNode() === node) return; // alias is the public export name — leave it
if (!parent.getAliasNode()) parent.setAlias(oldName);
parent.getNameNode().replaceWithText(newName);
return;
}(Equivalently, if (parent.getNameNode() !== node) return;.) A test like import { McpError } from '@modelcontextprotocol/server'; class Foo {} export { Foo as McpError }; → expect output to contain export { Foo as McpError } would lock this in.
| export function isImportedFromMcp(sourceFile: SourceFile, symbolName: string): boolean { | ||
| return sourceFile.getImportDeclarations().some(imp => { | ||
| if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false; | ||
| return imp.getNamedImports().some(n => { | ||
| const localName = n.getAliasNode()?.getText() ?? n.getName(); | ||
| return localName === symbolName; | ||
| }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
🟡 The latest commit changed isImportedFromMcp() to compare only against the local binding (n.getAliasNode()?.getText() ?? n.getName()), but mcpServerApi.ts:15 and expressMiddleware.ts:12 still pass hardcoded export names — exactly what unresolved comment 3137901202 warned against. So import { McpServer as Server } from '...' now makes isImportedFromMcp(sf, 'McpServer') return false and the entire mcpserver-api transform silently no-ops, regressing previously-working aliased-import handling. Add a separate export-name helper for the two file-level gates (note: simply matching either name would re-break the handlerRegistration false-positive the latest commit just fixed).
Extended reasoning...
What the bug is
isImportedFromMcp (importUtils.ts:76-84) was changed in the latest commit from n.getName() === symbolName (matching the export name) to:
const localName = n.getAliasNode()?.getText() ?? n.getName();
return localName === symbolName;i.e., it now matches only the local binding name. This was done to fix the false-positive flagged in comment 3137901202 for handlerRegistration.ts:40 and schemaParamRemoval.ts:31, which pass body-identifier text (a local name). But that same comment explicitly warned: "mcpServerApi.ts:15 and expressMiddleware.ts:12 pass hardcoded export names and rely on the current semantics, so the fix is to check the local binding only for the body-identifier callers — e.g., a separate helper or an optional flag — rather than changing the function unconditionally." The function was changed unconditionally anyway, and those two callers were not updated.
Code path that triggers it
mcpServerApi.ts:15 calls isImportedFromMcp(sourceFile, 'McpServer') as a file-level gate; expressMiddleware.ts:12 calls isImportedFromMcp(sourceFile, 'hostHeaderValidation'). Both pass the export name as a literal string. With an aliased import, ts-morph's getAliasNode()?.getText() returns the alias, so localName is the alias — which never equals the hardcoded export name.
Why this is a regression
Before the latest commit, for import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js', n.getName() === 'McpServer' was true, the gate passed, and the collection loop (which has no receiver check) correctly migrated server.tool(...) → server.registerTool(...). After the change, localName = 'Server', 'Server' !== 'McpServer', the gate returns false, and the transform returns { changesCount: 0 } without touching any .tool()/.prompt()/.resource() calls. No diagnostic is emitted. No test in mcpServerApi.test.ts covers an aliased McpServer import, so the regression is uncaught. (For expressMiddleware, the gate has the same issue, though the body check at line 32 was already literal-name-based — so the net effect there is unchanged; the observable regression is in mcpServerApi.)
Step-by-step proof
Input:
import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js';
const s = new Server({ name: 'test', version: '1.0' });
s.tool('greet', async () => ({ content: [] }));mcpServerApiTransform.apply()runs; line 15 callsisImportedFromMcp(sf, 'McpServer').- importUtils.ts:78 — the import's specifier passes
isAnyMcpSpecifier. - importUtils.ts:80 — the single named import has
getAliasNode()?.getText() = 'Server', solocalName = 'Server'. - importUtils.ts:81 —
'Server' === 'McpServer'→ false..some()returns false. - mcpServerApi.ts:15-17 — gate fails →
return { changesCount: 0, diagnostics: [] }. s.tool('greet', ...)is never migrated. AfterimportPathsrewrites the import to@modelcontextprotocol/server, the user is left with a v2 import and an un-migrated v1.tool()call that produces a TypeScript error — with zero codemod diagnostics pointing at it.
Impact
Aliasing McpServer (e.g., as Server to avoid the Mcp prefix, or to disambiguate from another Server class) is uncommon but realistic. The failure mode is safe — the source is left unchanged rather than corrupted, and TypeScript will flag the unmigrated .tool() calls — hence nit severity. But it's a regression from the immediately-preceding commit, it was explicitly predicted in the still-unresolved review comment, and the fix is small.
Fix
Do not change isImportedFromMcp to match either export-or-local name — that would re-introduce the handlerRegistration false positive (and break the new test 'does not rewrite local import when aliased MCP import has same export name'). Instead, give the file-level gates an export-name check:
// importUtils.ts
export function isMcpExportImported(sourceFile: SourceFile, exportName: string): boolean {
return sourceFile.getImportDeclarations().some(imp =>
isAnyMcpSpecifier(imp.getModuleSpecifierValue()) &&
imp.getNamedImports().some(n => n.getName() === exportName)
);
}Use it at mcpServerApi.ts:15 and expressMiddleware.ts:12. (Or add a { matchExportName?: boolean } flag to the existing helper.) A test with import { McpServer as Server } + s.tool(...) → expect registerTool would lock this in.
|
@claude review |
| const hasAlias = namedImports.some(n => n.getAliasNode() !== undefined); | ||
| if (defaultImport || namespaceImport || hasAlias) { | ||
| let effectiveTarget = targetPackage; | ||
| if (mapping.symbolTargetOverrides && !namespaceImport && !defaultImport) { | ||
| const allOverridden = namedImports.length > 0 && namedImports.every(n => n.getName() in mapping.symbolTargetOverrides!); | ||
| if (allOverridden) { | ||
| effectiveTarget = mapping.symbolTargetOverrides[namedImports[0]!.getName()]!; | ||
| } | ||
| } | ||
| imp.setModuleSpecifier(effectiveTarget); | ||
| if (mapping.renamedSymbols) { | ||
| for (const n of namedImports) { | ||
| const newName = mapping.renamedSymbols[n.getName()]; | ||
| if (newName) { | ||
| n.setName(newName); | ||
| } | ||
| } | ||
| if (namespaceImport) { | ||
| diagnostics.push( | ||
| warning( | ||
| filePath, | ||
| line, | ||
| `Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` + | ||
| `were renamed in ${effectiveTarget}. Update qualified accesses manually.` | ||
| ) | ||
| ); | ||
| } | ||
| } | ||
| changesCount++; | ||
| continue; |
There was a problem hiding this comment.
🟡 When any specifier in a named import is aliased, the in-place setModuleSpecifier branch (line 107) fires, and the allOverridden check at line 110 only redirects to the override package when every specifier is in symbolTargetOverrides — so a mixed aliased import like import { StreamableHTTPServerTransport as T, EventStore } from '.../server/streamableHttp.js' is rewritten to import { NodeStreamableHTTPServerTransport as T, EventStore } from '@modelcontextprotocol/server', which doesn't export NodeStreamableHTTPServerTransport (TS2305), with no diagnostic. The non-aliased path at lines 138-144 already routes per-symbol via mapping.symbolTargetOverrides?.[name] ?? targetPackage; consider falling through to it (it can carry aliases via addPending) or at minimum push a warning(...) when hasAlias && symbolTargetOverrides && !allOverridden. (rewriteExportDeclarations at line 222 has the same all-or-nothing gap for mixed re-exports.)
Extended reasoning...
What the bug is
The newly-added symbolTargetOverrides mechanism lets a single v1 module map different symbols to different v2 packages — currently used only for @modelcontextprotocol/sdk/server/streamableHttp.js, where StreamableHTTPServerTransport goes to @modelcontextprotocol/node while everything else (EventStore, StreamId, etc.) goes to @modelcontextprotocol/server. The non-aliased path at importPaths.ts:138-144 handles this correctly by routing each specifier individually:
const symbolTarget = mapping.symbolTargetOverrides?.[name] ?? targetPackage;
addPending(symbolTarget, [resolvedName], specifierTypeOnly);But line 106 computes hasAlias = namedImports.some(n => n.getAliasNode() !== undefined), and if any specifier is aliased the whole declaration is sent into the in-place setModuleSpecifier branch (107-136). That branch can only set one module specifier, and the allOverridden guard at line 110 only redirects to the override package when every named import is a key in symbolTargetOverrides. For a mixed import, allOverridden is false, effectiveTarget stays at the base targetPackage (@modelcontextprotocol/server), and the renamed transport ends up imported from the wrong package.
Code path that triggers it
Input:
import { StreamableHTTPServerTransport as T, EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';(Note that aliasing just one of the two specifiers is enough — .some() at line 106 fires on a single match.)
mapping = { target: '@modelcontextprotocol/server', renamedSymbols: { StreamableHTTPServerTransport: 'NodeStreamableHTTPServerTransport' }, symbolTargetOverrides: { StreamableHTTPServerTransport: '@modelcontextprotocol/node' } }.- Lines 100-104:
renameAllReferences(sourceFile, 'StreamableHTTPServerTransport', 'NodeStreamableHTTPServerTransport')runs (no-op here since the body uses the aliasT). - Line 106:
hasAlias = true→ enters the in-place branch. - Line 110:
namedImports.every(n => n.getName() in {StreamableHTTPServerTransport: ...})—EventStoreis not a key →allOverridden = false. effectiveTargetstays'@modelcontextprotocol/server'; line 115imp.setModuleSpecifier('@modelcontextprotocol/server').- Lines 117-121: the specifier
StreamableHTTPServerTransportis renamed toNodeStreamableHTTPServerTransport(aliasTpreserved).
Output:
import { NodeStreamableHTTPServerTransport as T, EventStore } from '@modelcontextprotocol/server';→ TS2305: Module '"@modelcontextprotocol/server"' has no exported member 'NodeStreamableHTTPServerTransport'. No diagnostic is emitted (the only warning(...) in this branch is gated on namespaceImport).
Why existing code/tests don't catch it
The two existing tests bracket this case without covering it: 'handles aliased renamedSymbols correctly' (importPaths.test.ts:165) tests a single aliased specifier (so allOverridden is true → routes to /node ✓), and 'splits streamableHttp import: transport to /node, types to /server' (importPaths.test.ts:177) tests a mixed but unaliased import (so hasAlias is false → per-symbol addPending path ✓). The aliased-and-mixed combination falls between them.
The same all-or-nothing logic appears in rewriteExportDeclarations (line 222-227) — export { StreamableHTTPServerTransport, EventStore } from '.../streamableHttp.js' becomes export { NodeStreamableHTTPServerTransport as StreamableHTTPServerTransport, EventStore } from '@modelcontextprotocol/server' with the same TS2305 — and in rewriteMockCall / rewriteDynamicImports (mockPaths.ts), though those cases are even rarer.
Impact
Nit severity: only one IMPORT_MAP entry has symbolTargetOverrides, importing the transport and EventStore/StreamId from streamableHttp.js in a single declaration with at least one alias is uncommon, and the resulting TS2305 is immediate and points directly at the offending specifier. The codemod is also documented as 80-90% coverage. But it's an internal inconsistency in newly-added logic — the per-symbol routing was clearly intended (it's implemented for the common case three lines below), and this branch silently produces broken output with no diagnostic.
Step-by-step proof
Input: import { StreamableHTTPServerTransport as Transport, EventStore as Store } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
- importPaths.ts:106 —
namedImports = [⟨StreamableHTTPServerTransport as Transport⟩, ⟨EventStore as Store⟩];.some(n => n.getAliasNode())→ true. - :107 —
defaultImport=undefined,namespaceImport=undefined,hasAlias=true→ enter branch. - :109 —
mapping.symbolTargetOverridestruthy,!namespaceImport && !defaultImport→ enter. - :110 —
namedImports.every(...):'StreamableHTTPServerTransport' in overrides✓,'EventStore' in overrides✗ → false. - :111-113 — skipped;
effectiveTarget = '@modelcontextprotocol/server'. - :115 —
imp.setModuleSpecifier('@modelcontextprotocol/server'). - :117-121 —
n.getName()='StreamableHTTPServerTransport'→n.setName('NodeStreamableHTTPServerTransport');n.getName()='EventStore'→ no rename. - :123 —
namespaceImportfalsy → no warning. - :134-135 —
changesCount++;continue.
Final: import { NodeStreamableHTTPServerTransport as Transport, EventStore as Store } from '@modelcontextprotocol/server'; — NodeStreamableHTTPServerTransport is exported from @modelcontextprotocol/node, not /server. diagnostics = [].
Fix
Either drop the hasAlias short-circuit and let aliased named-only imports fall through to the per-symbol addPending loop (extending PendingImport to carry an alias?: string so addOrMergeImport can pass { name, alias } specifiers — ts-morph's insertImportDeclaration already accepts that shape), or keep the branch but split the declaration in place when symbolTargetOverrides && !allOverridden: move the overridden specifiers to a new import for the override target and leave the rest on targetPackage. At minimum, push a warning(filePath, line, ...) for this case so it's not silent.
| function collectFactorySymbols(factoryArg: import('ts-morph').Node): string[] { | ||
| const symbols: string[] = []; | ||
| factoryArg.forEachDescendant(node => { | ||
| if (Node.isPropertyAssignment(node) || Node.isShorthandPropertyAssignment(node)) { | ||
| symbols.push(node.getName()); | ||
| } | ||
| }); | ||
| return symbols; | ||
| } |
There was a problem hiding this comment.
🟡 collectFactorySymbols uses forEachDescendant to collect property names at any depth, so for the canonical class-mocking pattern vi.mock('.../server/streamableHttp.js', () => ({ StreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({ handleRequest: vi.fn() })) })) the nested handleRequest key is collected, allOverridden (line 105) is false, and the mock specifier is rewritten to @modelcontextprotocol/server instead of @modelcontextprotocol/node — so the mock no longer intercepts the module the migrated code actually imports the transport from. Only inspect the top-level properties of the factory's returned object literal (find the return value and iterate .getProperties()); the same depth issue in renameSymbolsInFactory (lines 131-153) would also rename a nested { McpError: ... } instance-property key inside a .mockImplementation body.
Extended reasoning...
What the bug is
collectFactorySymbols (mockPaths.ts:121-129) does:
factoryArg.forEachDescendant(node => {
if (Node.isPropertyAssignment(node) || Node.isShorthandPropertyAssignment(node)) {
symbols.push(node.getName());
}
});forEachDescendant visits every PropertyAssignment/ShorthandPropertyAssignment at any nesting depth inside the factory argument — not just the top-level keys of the object the factory returns. The result feeds the symbolTargetOverrides heuristic at line 105: factorySymbols.every(s => s in resolved.symbolTargetOverrides!). That check is meant to ask "is every export this factory mocks one that moved to the override package?" — but because nested object-literal keys (instance methods, config objects, etc.) leak into the list, the .every() is false whenever the factory contains any nested object literal.
Code path that triggers it
The canonical vitest/jest pattern for mocking a class is:
vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
StreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({
handleRequest: vi.fn(),
sessionId: 'test-session'
}))
}));Walking through rewriteMockCall:
resolveTarget(...)→{ target: '@modelcontextprotocol/server', renamedSymbols: { StreamableHTTPServerTransport: 'NodeStreamableHTTPServerTransport' }, symbolTargetOverrides: { StreamableHTTPServerTransport: '@modelcontextprotocol/node' } }.- Line 103-104:
collectFactorySymbols(args[1])walks the arrow function.forEachDescendantvisits the outerStreamableHTTPServerTransport: ...assignment and the innerhandleRequest: vi.fn()andsessionId: 'test-session'assignments → returns['StreamableHTTPServerTransport', 'handleRequest', 'sessionId']. - Line 105:
['StreamableHTTPServerTransport', 'handleRequest', 'sessionId'].every(s => s in { StreamableHTTPServerTransport: '@modelcontextprotocol/node' })→'handleRequest' in {...}is false →allOverridden = false. effectiveTargetstays atresolved.target='@modelcontextprotocol/server'.- Line 110:
firstArg.setLiteralValue('@modelcontextprotocol/server'). renameSymbolsInFactorythen renames the top-level key toNodeStreamableHTTPServerTransport.
Output:
vi.mock('@modelcontextprotocol/server', () => ({
NodeStreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({ handleRequest: vi.fn(), sessionId: 'test-session' }))
}));But the migrated production code now does import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node' (per importPathsTransform's symbolTargetOverrides handling). The mock intercepts @modelcontextprotocol/server, not @modelcontextprotocol/node, so vitest/jest loads the real transport — the test silently exercises real I/O (or throws on construction) with no codemod diagnostic explaining why.
Why existing code doesn't prevent it
The existing test 'renames symbols in vi.doMock factory for streamableHttp' (mockPaths.test.ts:31-41) uses a flat factory () => ({ StreamableHTTPServerTransport: mockTransport }) with no nested properties, so collectFactorySymbols returns exactly ['StreamableHTTPServerTransport'], allOverridden is true, and /node is correctly chosen. Nothing exercises a nested .mockImplementation(() => ({...})) body, even though that's the dominant real-world shape for mocking a class constructor.
The same forEachDescendant depth issue exists in renameSymbolsInFactory (lines 131-153): a nested { McpError: someValue } inside a .mockImplementation body — i.e., an instance property on the mock, not a module export — would be renamed to ProtocolError, silently changing the mock's instance shape. That collision is less likely than the handleRequest case but is the same root cause.
Impact
When triggered, the user's migrated test file mocks the wrong module: the mock factory provides NodeStreamableHTTPServerTransport on @modelcontextprotocol/server (which doesn't export it), while the code under test imports it from @modelcontextprotocol/node and gets the real class. The failure surfaces as a test failure the user has to debug manually, with no codemod diagnostic — exactly the silent-test-behavior-change a migration tool should avoid. Nit severity because: only one symbolTargetOverrides entry exists today, it only affects test files, and the user's own test suite will catch the broken mock (loudly, if not informatively). But vi.fn().mockImplementation(() => ({...methods})) is the most common shape for class mocks, so the heuristic is broken for the majority of real inputs it was added to handle.
Step-by-step proof
Input:
vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
StreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({ handleRequest: vi.fn() }))
}));- mockPaths.ts:80 —
specifier = '@modelcontextprotocol/sdk/server/streamableHttp.js';isSdkSpecifier→ true. - mockPaths.ts:82 —
resolveTargetreturns{ target: '@modelcontextprotocol/server', renamedSymbols: {...}, symbolTargetOverrides: { StreamableHTTPServerTransport: '@modelcontextprotocol/node' } }. - mockPaths.ts:102-103 —
resolved.symbolTargetOverridestruthy,args.length === 2→ callcollectFactorySymbols(args[1]). - mockPaths.ts:123 —
forEachDescendanton the arrow function visits, in order: the outer ObjectLiteralExpression'sPropertyAssignment(StreamableHTTPServerTransport: ...) → push'StreamableHTTPServerTransport'; then descends intovi.fn().mockImplementation(() => ({...})), reaches the inner ObjectLiteralExpression'sPropertyAssignment(handleRequest: vi.fn()) → push'handleRequest'. Returns['StreamableHTTPServerTransport', 'handleRequest']. - mockPaths.ts:105 —
.every(s => s in { StreamableHTTPServerTransport: '@modelcontextprotocol/node' }):'StreamableHTTPServerTransport'✓,'handleRequest'✗ → false. - mockPaths.ts:106 — skipped;
effectiveTargetremains'@modelcontextprotocol/server'. - mockPaths.ts:110 — specifier rewritten to
'@modelcontextprotocol/server'. - mockPaths.ts:115 —
renameSymbolsInFactoryrenames the outer key toNodeStreamableHTTPServerTransport(and leaveshandleRequestalone since it's not inallRenames).
Final: vi.mock('@modelcontextprotocol/server', () => ({ NodeStreamableHTTPServerTransport: ... })) — wrong target package.
Fix
Restrict collection to the top-level properties of the factory's returned object literal. Locate the returned expression (arrow body if a parenthesized object, or the ReturnStatement argument if a block body), guard with Node.isObjectLiteralExpression, and iterate .getProperties() filtered to PropertyAssignment | ShorthandPropertyAssignment:
function collectFactorySymbols(factoryArg: Node): string[] {
let returned: Node | undefined;
if (Node.isArrowFunction(factoryArg) || Node.isFunctionExpression(factoryArg)) {
const body = factoryArg.getBody();
returned = Node.isBlock(body)
? body.getStatements().find(Node.isReturnStatement)?.getExpression()
: body;
if (returned && Node.isParenthesizedExpression(returned)) returned = returned.getExpression();
}
if (!returned || !Node.isObjectLiteralExpression(returned)) return [];
return returned
.getProperties()
.filter(p => Node.isPropertyAssignment(p) || Node.isShorthandPropertyAssignment(p))
.map(p => (p as any).getName());
}Apply the same top-level-only restriction to renameSymbolsInFactory (or have it operate on the same returned.getProperties() list) so nested instance-property keys aren't renamed. Add a test with vi.fn().mockImplementation(() => ({ handleRequest: vi.fn() })) asserting the specifier becomes '@modelcontextprotocol/node'.
Adds a new
@modelcontextprotocol/codemodpackage that automatically migrates MCP TypeScript SDK code from v1 (@modelcontextprotocol/sdk) to the v2 multi-package architecture (@modelcontextprotocol/client,/server,/core,/node,/express). Powered byts-morphfor AST-level precision and shipped as both a CLI (mcp-codemod) and a programmatic API.Motivation and Context
The v2 SDK introduces a multi-package structure, renamed APIs, restructured context objects, and removed modules (WebSocket transport, server auth, Zod helpers). Manually migrating a codebase is tedious, error-prone, and blocks adoption. This codemod handles the mechanical 80-90% of migration — rewriting imports, renaming symbols, updating method signatures, and mapping context properties — while emitting actionable diagnostics for the remaining cases that require human judgment.
Architecture
Package Structure
High-Level Flow
flowchart TD A["mcp-codemod v1-to-v2 ./src"] --> B[CLI parses args] B --> C[Runner loads migration] C --> D[Analyze project type<br/>from package.json] D --> E[Create ts-morph Project<br/>glob .ts/.tsx/.mts files] E --> F[Filter out node_modules,<br/>dist, .d.ts, .d.mts] F --> G{For each<br/>source file} G --> H[Apply transforms<br/>in order] H --> I[Collect diagnostics<br/>& change counts] I --> G G -->|done| J{Dry run?} J -->|yes| K[Report changes<br/>without saving] J -->|no| L[Save modified files<br/>to disk] K --> M[Print summary:<br/>files changed,<br/>diagnostics] L --> MTransform Pipeline
Transforms run in a strict order per file. Each transform receives the
SourceFileAST, mutates it in place, and returns a change count plus diagnostics. One failing transform does not block the others.flowchart LR subgraph "Phase 1: Foundation" T1["1. importPaths<br/>Rewrite import specifiers<br/>from v1 → v2 packages"] end subgraph "Phase 2: Symbols" T2["2. symbolRenames<br/>McpError → ProtocolError<br/>ErrorCode split, etc."] T3["3. removedApis<br/>Drop Zod helpers,<br/>IsomorphicHeaders"] end subgraph "Phase 3: API Surface" T4["4. mcpServerApi<br/>.tool() → .registerTool()<br/>restructure args"] T5["5. handlerRegistration<br/>Schema → method string"] T6["6. schemaParamRemoval<br/>Remove schema args"] T7["7. expressMiddleware<br/>hostHeaderValidation<br/>signature update"] end subgraph "Phase 4: Context & Tests" T8["8. contextTypes<br/>extra → ctx,<br/>property path remapping"] T9["9. mockPaths<br/>vi.mock / jest.mock<br/>specifier updates"] end T1 --> T2 --> T3 --> T4 --> T5 & T6 & T7 --> T8 --> T9Import Resolution Strategy
Some v1 paths (e.g.,
@modelcontextprotocol/sdk/types.js) are shared code that could belong to either the client or server package. The codemod resolves these contextually:flowchart TD A["v1 import path"] --> B{Path in<br/>IMPORT_MAP?} B -->|no| Z[Leave unchanged] B -->|yes| C{Status?} C -->|removed| D["Remove import +<br/>emit warning diagnostic"] C -->|moved| E{Target is<br/>RESOLVE_BY_CONTEXT?} C -->|renamed| F["Rewrite path +<br/>rename symbols"] E -->|no| G["Rewrite to<br/>fixed target package"] E -->|yes| H{Project type?} H -->|client only| I["→ @modelcontextprotocol/client"] H -->|server only| J["→ @modelcontextprotocol/server"] H -->|both or unknown| K["→ @modelcontextprotocol/server<br/>(safe default)"]Context Property Remapping
The v2 SDK restructures the handler context from a flat object into nested groups. The
contextTypestransform handles this remapping:flowchart LR subgraph "v1 — flat RequestHandlerExtra" E1["extra.signal"] E2["extra.requestId"] E3["extra.authInfo"] E4["extra.sendRequest(...)"] E5["extra.sendNotification(...)"] E6["extra.taskStore"] end subgraph "v2 — nested BaseContext" C1["ctx.mcpReq.signal"] C2["ctx.mcpReq.id"] C3["ctx.http?.authInfo"] C4["ctx.mcpReq.send(...)"] C5["ctx.mcpReq.notify(...)"] C6["ctx.task?.store"] end E1 --> C1 E2 --> C2 E3 --> C3 E4 --> C4 E5 --> C5 E6 --> C6What Each Transform Does
imports@modelcontextprotocol/sdk/...import paths to their v2 package destinations. Merges duplicate imports, separates type-only imports, resolves ambiguous shared paths by project type.symbolsMcpError→ProtocolError). SplitsErrorCodeintoProtocolErrorCode+SdkErrorCodebased on member usage. ConvertsRequestHandlerExtra→ServerContext/ClientContext. ReplacesSchemaInput<T>withStandardSchemaWithJSON.InferInput<T>.removed-apisschemaToJson,parseSchemaAsync, etc.), renamesIsomorphicHeaders→Headers, convertsStreamableHTTPError→SdkErrorwith constructor mapping warnings.mcpserver-apiMcpServermethod calls:.tool()→.registerTool(),.prompt()→.registerPrompt(),.resource()→.registerResource(). Restructures 2-4 positional args into(name, config, callback)form. Wraps raw object schemas withz.object().handlersserver.setRequestHandler(CallToolRequestSchema, ...)toserver.setRequestHandler('tools/call', ...)using the schema-to-method mapping table. Covers 15 request schemas and 8 notification schemas.schema-params.request(),.callTool(), and.send()calls where the second argument is a schema reference (ends withSchema).express-middlewarehostHeaderValidation({ allowedHosts: [...] })→hostHeaderValidation([...]).contextextracallback parameter toctx. Rewrites 13 property access paths from the flat v1 context to the nested v2 context structure. Warns on destructuring patterns that need manual review.mock-pathsvi.mock()/jest.mock()specifiers from v1 to v2 paths. Updatesimport()expressions in mock factories. Renames symbol references inside mock factory return objects.CLI Usage
Programmatic API
How Has This Been Tested?
.d.tsexclusion, unknown transform ID validation.Breaking Changes
None — this is a new package with no existing users.
Types of changes
Checklist
Additional context
Design Decisions
ts-morph over jscodeshift: ts-morph provides full TypeScript type information and a more ergonomic API for the precise symbol-level transforms this codemod requires (e.g., distinguishing
ErrorCode.RequestTimeoutfromErrorCode.InvalidRequestfor theProtocolErrorCode/SdkErrorCodesplit).Ordered transforms with per-file isolation: Transforms run in a declared order (imports first, mocks last). If a transform throws for a given file, the remaining transforms are skipped for that file (since the AST may be in a partially-mutated state), but processing continues for other files. An error diagnostic is emitted for the affected file.
Declarative mapping tables: All rename/remap rules live in dedicated mapping files (
importMap.ts,symbolMap.ts, etc.) rather than being scattered across transform logic. This makes the migration rules auditable and easy to extend.Context-aware import resolution: Shared v1 paths like
@modelcontextprotocol/sdk/types.jsare resolved to client or server packages based onpackage.jsondependency analysis, not just hardcoded defaults.Diagnostic-first approach for removals: When the codemod encounters removed APIs (WebSocket transport, server auth, Zod helpers), it doesn't silently drop them — it emits clear warning diagnostics with migration guidance so users know what needs manual attention.